在 C++ 中如果不想要默认构造函数怎么办?
默认构造函数真的必要吗?有什么意义?如果没有默认构造函数会怎样?本文将探讨这些问题。
默认构造函数是一种不带参数的构造函数,它会给所有成员变量赋默认值。如果没有定义任何构造函数,编译器会自动生成一个。
是否需要默认构造函数?
这取决于具体情况。首先从设计角度来看,成员变量默认初始化是否合理?
比如,对于一个计时器设备,所有计数器初始化为零是合理的。
计时器是一种记录重型车辆驾驶员的驾驶时间、休息时间及其他工作的设备。
然而,对于一个人或者任务标识符(这也是我写这篇文章的灵感来源),默认构造函数就不合理了。一个名字为空的人的对象或者 ID 为空的任务标识符没有意义。
从设计角度,这无疑是如此。那么从技术角度来看呢?如果没有默认构造函数,会发生什么?
使用一些标准库类型会遇到困难。
#include <map>
#include <string>
#include <vector>
class TaskID {
public:
TaskID(std::string uuid): m_uuid(uuid){};
auto operator<=>(const TaskID&) const = default;
void serialize(std::string &out_buffer) const {
out_buffer.resize(sizeof(TaskID));
memcpy(out_buffer.data(), reinterpret_cast<const char *>(this), sizeof(TaskID));
}
private:
std::string m_uuid;
};
void foo(const TaskID& taskID) {
// ...
taskID.serialize();
}
int main() {
std::vector<TaskID> tasks;
// 编译错误,resize() 需要默认构造函数
// tasks.resize(10);
// 没有默认构造函数,不能执行
// std::vector<TaskID> moreTasks(10);
std::map<TaskID, std::string> tasksMap{ {TaskID{"ab12"}, "dummy"} };
tasksMap[TaskID{"ab13"}] = "other dummy";
std::map<int, TaskID> tasksMap2;
// 无法使用 operator[],值类型需要默认构造函数
// tasksMap2[4] = TaskID{"ab13"};
foo(tasksMap.at(42));
}
这只是两个例子。没有默认构造函数,使用 std::vector
或 std::map
可能会遇到麻烦。但这些限制并非无法克服。
还有更难接受的限制。
假设你有一个类 Widget
,它包含一个 TaskID
成员变量。虽然这是可能的,但该类不能有自动生成的默认构造函数,因为这会要求 TaskID
具有默认构造函数。
假设在构造时,我们无法提供有意义的 TaskID
值,只能以后确定。
#include <map>
#include <string>
#include <vector>
class TaskID {
public:
TaskID(std::string uuid): m_uuid(uuid){};
auto operator<=>(const TaskID&) const = default;
private:
std::string m_uuid;
};
class Widget {
public:
// ...
TaskID getTaskID() const { return taskId; }
private:
TaskID taskId;
};
void foo(const TaskID& taskID) {
// ...
taskID.serialize();
}
int main() {
// 错误:调用了隐式删除的 Widget 默认构造函数
Widget widget;
foo(widget.getTaskID());
}
可以为 Widget
提供一个默认构造函数,但如何实例化一个默认的 TaskID
或任何没有默认值的类呢?
仍然使用默认构造函数
可以给成员变量赋一些虚拟值。对于整数,通常用 -1 表示无效状态;对于字符串,可以用一个空值。事实上,大多数人不会深入思考这些问题,他们只是让对象使用成员的默认值。
如果看到代码无法编译,因为缺少默认构造函数,他们会直接添加一个。
如果更谨慎一些,他们可能会定义一个 isValid()
函数来判断对象是否有效。
class TaskID {
public:
TaskID() = default;
TaskID(std::string uuid): m_uuid(uuid){};
auto operator<=>(const TaskID&) const = default;
bool isValid() const {
return !m_uuid.empty();
}
private:
std::string m_uuid;
};
void foo(const TaskID& taskID) {
if (!taskID.isValid()) {
return;
}
// ...
taskID.serialize();
}
但如果不想创建无意义默认值的对象,我们有其他选择吗?
用 std::optional
包装对象
为了避免使用默认构造函数,同时还能将对象作为类成员或与标准容器一起使用,可以用 std::optional
包装 TaskID
。
#include <map>
#include <optional>
#include <string>
#include <vector>
class TaskID {
public:
TaskID(std::string uuid): m_uuid(uuid){};
auto operator<=>(const TaskID&) const = default;
private:
std::string m_uuid;
};
void foo(std::optional<TaskID> taskID) {
if (!taskID) {
return;
}
// ...
taskID.serialize();
}
int main() {
std::vector<std::optional<TaskID>> tasks;
tasks.resize(10);
std::vector<std::optional<TaskID>> moreTasks(10);
std::map<TaskID, std::string> tasksMap{ {TaskID{"ab12"}, "dummy"} };
tasksMap[TaskID{"ab13"}] = "other dummy";
std::map<int, std::optional<TaskID>> tasksMap2;
tasksMap2[4] = TaskID{"ab13"};
foo(tasksMap2[42]);
}
这意味着什么呢?
实际上,我们必须验证 TaskID
是否存在以避免错误访问。这增加了一层验证,可能会麻烦。
从语义上看,这意味着 TaskID
要么存在要么不存在。虽然这并不是我们完全想表达的,通常我们想说的是 TaskID
还不可用或者已经存在。而 std::optional
的表达能力到此为止。
使用 std::variant
std::variant<Ts...>
类似于增强版的 std::optional<T>
。它可以包含多种类型。因此,我们可以用 std::variant
表示 TaskID
、InvalidTaskID
或 UninitialziedTaskID
等。
class TaskID { /* 和之前一样 */ };
struct InvalidTaskID {};
struct UninitialziedTaskID {};
std::variant<UninitialziedTaskID, InvalidTaskID, TaskID> taskID;
我将 UninitialziedTaskID
放在第一个位置,因为默认情况下变量会初始化为它。
相比 std::optional
,它的使用并不复杂,但你可能需要在代码中使用大量的 std::holds_alternative<T>
和 std::get<T>
,这会影响可读性。
void foo(std::variant<UninitialziedTaskID, InvalidTaskID, TaskID> taskID) {
if (!std::holds_alternative<TaskID>(taskID)) {
return;
}
// ...
taskID.serialize();
}
void bar(std::variant<UninitialziedTaskID, InvalidTaskID, TaskID> taskID) {
try {
std::get<TaskID>(taskID).serialize();
} catch (std::bad_variant_access const& ex) {
std::cout << ex.what() << ": taskID 中不包含 TaskID\n";
}
}
使用 std::variant
结合 enum
还有一个有趣的选择。我们可以用 std::variant
结合枚举,将 TaskID
作为第二项,枚举作为第一项以表示无效状态。
#include <string>
#include <variant>
#include <vector>
enum class InvalidTaskIDStates {
InvalidTaskID,
UninitialziedTaskID,
};
class TaskID {
public:
TaskID(std::string uuid): m_uuid(uuid) {};
private:
std::string m_uuid;
};
void foo(std::variant<InvalidTaskIDStates, TaskID> taskID) {
if (std::holds_alternative<InvalidTaskIDStates>(taskID)) {
return;
}
// ...
taskID.serialize();
}
void bar(std::variant<InvalidTaskIDStates, TaskID> taskID) {
if (std::holds_alternative<InvalidTaskIDStates>(taskID)) {
switch (std::get<InvalidTaskIDStates>(taskID)) {
case InvalidTaskIDStates::InvalidTaskID:
std::cout << "InvalidTaskID\n";
return;
case InvalidTaskIDStates::UninitialziedTaskID:
std::cout << "UninitialziedTaskID\n";
return;
}
}
// ...
taskID.serialize();
}
int main() {
std::variant<InvalidTaskIDStates, TaskID> myvar;
std::vector<std::variant<InvalidTaskIDStates, TaskID>> tasks;
tasks.resize(10);
tasks.push_back(TaskID{"ab12"});
foo(tasks.back());
bar(tasks.front());
}
优点是,如果我们对无效状态感兴趣,只需检查一个变体。但如果我们对具体的无效状态感兴趣,需要结合 std::variant
的 API 和枚举语法。
总 结
今天,我们探讨了不希望类型有默认构造函数的几种选择。即使在这种情况下,具有默认构造函数可能也有意义,能将对象初始化为无效状态。尽管这不是最佳实践。
没有默认构造函数的类型有时难以与容器结合使用。当需要组合其他具有默认构造函数的类型,但某个成员没有默认构造函数时,我们可以将这些类型包装到 std::optional
或 std::variant
中。
当然,我们也可以使用智能指针,但这不是我们讨论的范围,因为动态内存分配并不是解决原始问题的方法。